Allez au-delà des tests traditionnels basés sur des exemples. Ce guide complet explore le test basé sur les propriétés en JavaScript avec fast-check, vous aidant à trouver plus de bugs avec moins de code.
Au-delà des exemples : Une exploration approfondie du test basé sur les propriétés en JavaScript
En tant que développeurs de logiciels, nous passons un temps considérable à écrire des tests. Nous élaborons méticuleusement des tests unitaires, d'intégration et de bout en bout pour garantir que nos applications sont robustes, fiables et sans régressions. Le paradigme dominant pour cela est le test basé sur des exemples. Nous pensons à une entrée spécifique, et nous affirmons une sortie spécifique. L'entrée `[1, 2, 3]` devrait produire la sortie `6`. L'entrée `"hello"` devrait devenir `"HELLO"`. Mais cette approche a une faiblesse silencieuse et latente : notre propre imagination.
Et si vous oubliez de tester avec un tableau vide ? Un nombre négatif ? Une chaîne contenant des caractères Unicode ? Un objet profondément imbriqué ? Chaque cas limite manqué est un bug potentiel qui ne demande qu'à se produire. C'est là que le Test Basé sur les Propriétés (PBT) entre en scène, offrant un puissant changement de paradigme qui nous aide à construire des logiciels plus fiables et résilients.
Ce guide complet vous fera découvrir le monde du test basé sur les propriétés en JavaScript. Nous explorerons ce que c'est, pourquoi c'est si efficace, et comment vous pouvez l'implémenter dans vos projets dès aujourd'hui en utilisant la populaire bibliothèque `fast-check`.
Les limites du test traditionnel basé sur des exemples
Considérons une fonction simple qui trie un tableau de nombres. En utilisant un framework populaire comme Jest ou Vitest, notre test pourrait ressembler à ceci :
// Une fonction de tri simple (et un peu naïve)
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
// Un test typique basé sur des exemples
test('sortNumbers devrait trier correctement un tableau simple', () => {
const inputArray = [3, 1, 4, 1, 5, 9];
const expectedArray = [1, 1, 3, 4, 5, 9];
expect(sortNumbers(inputArray)).toEqual(expectedArray);
});
Ce test passe. Nous pourrions ajouter quelques blocs `it` ou `test` supplémentaires :
- Un tableau déjà trié.
- Un tableau avec des nombres négatifs.
- Un tableau avec un zéro.
- Un tableau vide.
- Un tableau avec des nombres en double (que nous avons déjà couvert).
Nous sommes satisfaits. Nous avons couvert les bases. Mais qu'avons-nous manqué ? Qu'en est-il de `[-0, 0]` ? Qu'en est-il de `[Infinity, -Infinity]` ? Qu'en est-il d'un très grand tableau qui pourrait atteindre des limites de performance ou des optimisations étranges du moteur JavaScript ? Le problème fondamental est que nous sélectionnons manuellement les données. Nos tests ne valent que ce que valent les exemples que nous pouvons concevoir, et les humains sont notoirement mauvais pour imaginer toutes les manières étranges et merveilleuses dont les données peuvent être structurées.
Le test basé sur des exemples valide que votre code fonctionne pour quelques scénarios triés sur le volet. Le test basé sur les propriétés valide que votre code fonctionne pour des classes entières d'entrées.
Qu'est-ce que le test basé sur les propriétés ? Un changement de paradigme
Le test basé sur les propriétés inverse le script. Au lieu d'affirmer qu'une entrée spécifique produit une sortie spécifique, vous définissez une propriété générale de votre code qui doit rester vraie pour n'importe quelle entrée valide. Le framework de test génère alors des centaines ou des milliers d'entrées aléatoires pour essayer de prouver que votre propriété est fausse.
Une "propriété" est un invariant — une règle de haut niveau sur le comportement de votre fonction. Pour notre fonction `sortNumbers`, certaines propriétés pourraient être :
- Idempotence : Trier un tableau déjà trié ne devrait pas le modifier. `sortNumbers(sortNumbers(arr))` devrait être identique à `sortNumbers(arr)`.
- Invariance de la longueur : Le tableau trié doit avoir la même longueur que le tableau d'origine.
- Invariance du contenu : Le tableau trié doit contenir exactement les mêmes éléments que le tableau d'origine, juste dans un ordre différent.
- Ordre : Pour deux éléments adjacents quelconques dans le tableau trié, `sorted[i] <= sorted[i+1]`.
Cette approche vous fait passer de la réflexion sur des exemples individuels à la réflexion sur le contrat fondamental de votre code. Ce changement de mentalité est incroyablement précieux pour concevoir des API meilleures et plus prévisibles.
Les composants clés du PBT
Un framework de test basé sur les propriétés a généralement deux composants clés :
- Générateurs (ou Arbitraires) : Ils sont responsables de la production d'une large gamme de données aléatoires selon des types spécifiés (entiers, chaînes de caractères, tableaux d'objets, etc.). Ils sont assez intelligents pour générer non seulement des données du "cas nominal" mais aussi des cas limites délicats comme des chaînes vides, `NaN`, `Infinity`, et plus encore.
- La réduction (shrinking) : C'est l'ingrédient magique. Lorsque le framework trouve une entrée qui falsifie votre propriété (c'est-à-dire qui provoque l'échec d'un test), il ne se contente pas de rapporter la grande entrée aléatoire. Au lieu de cela, il essaie systématiquement de trouver l'entrée la plus petite et la plus simple qui provoque encore l'échec. Cela rend le débogage exponentiellement plus facile.
Pour commencer : Implémenter le PBT avec `fast-check`
Bien qu'il existe plusieurs bibliothèques de PBT dans l'écosystème JavaScript, `fast-check` est un choix mature, puissant et bien maintenu. Il s'intègre de manière transparente avec les frameworks de test populaires comme Jest, Vitest, Mocha et Jasmine.
Installation et configuration
Tout d'abord, ajoutez `fast-check` aux dépendances de développement de votre projet. Nous supposerons que vous utilisez un exécuteur de tests comme Jest.
npm install --save-dev fast-check jest
# ou
yarn add --dev fast-check jest
# ou
pnpm add -D fast-check jest
Votre premier test basé sur les propriétés
Réécrivons notre test `sortNumbers` en utilisant `fast-check`. Nous allons tester la propriété "ordre" que nous avons définie plus tôt : chaque élément doit être inférieur ou égal à celui qui le suit.
import * as fc from 'fast-check';
// La même fonction qu'avant
function sortNumbers(arr) {
return [...arr].sort((a, b) => a - b);
}
test('la sortie de sortNumbers devrait être un tableau trié', () => {
// 1. Décrire la propriété
fc.assert(
// 2. Définir les arbitraires (générateurs d'entrées)
fc.property(fc.array(fc.integer()), (data) => {
// `data` est un tableau d'entiers généré aléatoirement
const sorted = sortNumbers(data);
// 3. Définir le prédicat (la propriété à vérifier)
for (let i = 0; i < sorted.length - 1; ++i) {
if (sorted[i] > sorted[i + 1]) {
return false; // La propriété est falsifiée
}
}
return true; // La propriété est respectée pour cette entrée
})
);
});
test('le tri ne devrait pas changer la longueur du tableau', () => {
fc.assert(
fc.property(fc.array(fc.float()), (data) => {
const sorted = sortNumbers(data);
return sorted.length === data.length;
})
);
});
Détaillons cela :
- `fc.assert()` : C'est l'exécuteur. Il exécutera votre vérification de propriété de nombreuses fois (100 par défaut).
- `fc.property()` : Définit la propriété elle-même. Il prend un ou plusieurs arbitraires en arguments, suivis d'une fonction prédicat.
- `fc.array(fc.integer())` : C'est notre arbitraire. Il indique à `fast-check` de générer un tableau (`fc.array`) d'entiers (`fc.integer()`). `fast-check` générera automatiquement des tableaux de différentes longueurs, avec différentes valeurs entières (positives, négatives, zéro, etc.).
- Le Prédicat : La fonction anonyme `(data) => { ... }` est l'endroit où se trouve notre logique. Elle reçoit les données générées aléatoirement et doit retourner `true` si la propriété est respectée ou `false` si elle est violée. `fast-check` prend également en charge les fonctions prédicat qui lèvent une erreur en cas d'échec, ce qui s'intègre bien avec les assertions `expect` de Jest.
Maintenant, au lieu d'un seul test avec un tableau choisi à la main, nous avons un test qui vérifie notre logique de tri par rapport à 100 tableaux différents, générés automatiquement, à chaque fois que nous exécutons notre suite de tests. Nous avons massivement augmenté notre couverture de test avec seulement quelques lignes de code.
Explorer les arbitraires : Générer les bonnes données
La puissance du PBT réside dans sa capacité à générer des données diverses et complexes. `fast-check` fournit un riche ensemble d'arbitraires pour couvrir presque toutes les structures de données que vous pouvez imaginer.
Arbitraires de base
Ce sont les briques de base pour votre génération de données.
- `fc.integer()`, `fc.float()`, `fc.bigInt()` : Pour les nombres. Ils peuvent être contraints, ex: `fc.integer({ min: 0, max: 100 })`.
- `fc.string()`, `fc.asciiString()`, `fc.unicodeString()` : Pour des chaînes de divers jeux de caractères.
- `fc.boolean()` : Pour `true` ou `false`.
- `fc.constant(value)` : Retourne toujours la même valeur. Utile pour combiner avec `fc.oneof`.
- `fc.constantFrom(val1, val2, ...)` : Retourne l'une des valeurs constantes fournies.
Arbitraires complexes et composés
Vous pouvez combiner des arbitraires de base pour créer des structures de données complexes.
- `fc.array(arbitrary, constraints)` : Génère un tableau d'éléments créés par l'arbitraire fourni. Vous pouvez contraindre `minLength` et `maxLength`.
- `fc.tuple(arb1, arb2, ...)` : Génère un tableau de longueur fixe où chaque élément a un type spécifique et différent.
- `fc.object(shape)` : Génère des objets avec une structure définie. Exemple : `fc.object({ id: fc.uuidV(4), name: fc.string() })`.
- `fc.oneof(arb1, arb2, ...)` : Génère une valeur à partir de l'un des arbitraires fournis. C'est excellent pour tester des fonctions qui gèrent plusieurs types de données (ex: `string | number`).
- `fc.record({ key: arb, value: arb })` : Génère des objets à utiliser comme des dictionnaires ou des maps, où les clés et les valeurs sont générées à partir d'arbitraires.
Créer des arbitraires personnalisés avec `map` et `chain`
Parfois, vous avez besoin de données qui ne correspondent pas à une forme standard. `fast-check` vous permet de créer vos propres arbitraires en transformant ceux qui existent déjà.
Utiliser `.map()`
La méthode `.map()` transforme la sortie d'un arbitraire en autre chose. Par exemple, créons un arbitraire qui génère des chaînes de caractères non vides.
const nonEmptyStringArb = fc.string({ minLength: 1 });
// Ou, en transformant un tableau de caractères
const nonAStringArb = fc.array(fc.char().filter(c => c !== 'a'))
.map(chars => chars.join(''));
Utiliser `.chain()`
La méthode `.chain()` est plus puissante. Elle vous permet de créer un nouvel arbitraire basé sur la valeur générée d'un précédent. C'est essentiel pour créer des données corrélées.
Imaginez que vous ayez besoin de générer un tableau puis un index valide pour ce même tableau. Vous ne pouvez pas faire cela avec deux arbitraires séparés, car l'index pourrait être hors limites. `.chain()` résout ce problème parfaitement.
// Générer un tableau et un index valide pour celui-ci
const arrayAndValidIndexArb = fc.array(fc.anything()).chain(arr => {
// Basé sur le tableau généré `arr`, créer un nouvel arbitraire pour l'index
const indexArb = fc.integer({ min: 0, max: arr.length - 1 });
// Retourner un tuple du tableau et de l'index généré
return fc.tuple(fc.constant(arr), indexArb);
});
// Utilisation dans un test
test('le découpage à un index valide devrait fonctionner', () => {
fc.assert(
fc.property(arrayAndValidIndexArb, ([arr, index]) => {
// `arr` et `index` sont garantis d'être compatibles
const sliced = arr.slice(0, index);
expect(sliced.length).toBe(index);
})
);
});
La puissance de la réduction (shrinking) : Le débogage facilité
La fonctionnalité la plus convaincante du test basé sur les propriétés est la réduction (shrinking). Pour la voir en action, créons une fonction délibérément boguée.
// Cette fonction échoue si le tableau d'entrée contient le nombre 42
function sumWithoutBug(arr) {
if (arr.includes(42)) {
throw new Error('This number is not allowed!');
}
return arr.reduce((acc, val) => acc + val, 0);
}
test('sumWithoutBug devrait additionner les nombres', () => {
fc.assert(
fc.property(fc.array(fc.integer()), (data) => {
sumWithoutBug(data);
})
);
});
Lorsque vous exécutez ce test, `fast-check` trouvera presque certainement un cas d'échec. Mais il ne rapportera pas le premier tableau aléatoire qu'il a trouvé, qui pourrait être quelque chose comme `[-1024, 500, 42, 987, -2000]`. Un rapport d'échec comme celui-ci n'est pas très utile. Vous devriez l'inspecter manuellement pour trouver le `42` problématique.
Au lieu de cela, le réducteur de `fast-check` entrera en jeu. Il verra l'échec et commencera à simplifier l'entrée :
- Puis-je retirer un élément ? Essayer `[500, 42, 987, -2000]`. Échoue toujours. Bien.
- Puis-je en retirer un autre ? Essayer `[42, 987, -2000]`. Échoue toujours.
- ... et ainsi de suite, jusqu'à ce qu'il ne puisse plus retirer d'éléments sans que le test ne réussisse.
- Il essaiera également de réduire les nombres. Est-ce que `42` peut être `0` ? Non, le test passe. Est-ce que ça peut être `41` ? Le test passe. Il affine le problème.
Le rapport d'erreur final ressemblera à quelque chose comme ceci :
Erreur : La propriété a échoué après 15 tests
{ seed: 12345678, path: "14", endOnFailure: true }
Contre-exemple : [[42]]
Réduit 5 fois
Erreur obtenue : This number is not allowed!
Il vous indique l'entrée exacte et minimale qui a causé l'échec : un tableau contenant uniquement le nombre `[42]`. Cela vous dirige immédiatement vers la source du bug, vous faisant gagner un temps et des efforts considérables en débogage.
Stratégies PBT pratiques et exemples concrets
Le PBT n'est pas seulement pour les fonctions mathématiques. C'est un outil polyvalent qui peut être appliqué à de nombreux domaines du développement logiciel.
Propriété : Fonctions inverses
Si vous avez une fonction qui encode des données et une autre qui les décode, elles sont inverses l'une de l'autre. Une excellente propriété à tester est que le décodage d'une valeur encodée devrait toujours retourner la valeur originale.
// `encode` et `decode` pourraient être pour base64, des composants URI, ou une sérialisation personnalisée
function encode(obj) { return JSON.stringify(obj); }
function decode(str) { return JSON.parse(str); }
test('decode(encode(x)) devrait être égal à x', () => {
// `fc.jsonValue()` génère n'importe quelle valeur JSON valide : chaînes, nombres, objets, tableaux
fc.assert(
fc.property(fc.jsonValue(), (originalValue) => {
const encoded = encode(originalValue);
const decoded = decode(encoded);
expect(decoded).toEqual(originalValue);
})
);
});
Propriété : Idempotence
Une opération est idempotente si l'appliquer plusieurs fois a le même effet que de l'appliquer une seule fois. `f(f(x)) === f(x)`. C'est une propriété cruciale pour des choses comme les fonctions de nettoyage de données ou les points de terminaison `DELETE` dans une API REST.
// Une fonction qui supprime les espaces de début/fin et réduit les espaces multiples
function normalizeWhitespace(text) {
return text.trim().replace(/\s+/g, ' ');
}
test('normalizeWhitespace devrait être idempotente', () => {
fc.assert(
fc.property(fc.string(), (originalString) => {
const once = normalizeWhitespace(originalString);
const twice = normalizeWhitespace(once);
expect(twice).toBe(once);
})
);
});
Propriété : Test à état (basé sur un modèle)
C'est une technique plus avancée mais incroyablement puissante pour tester des systèmes avec un état interne, comme un composant d'interface utilisateur, un panier d'achat ou une machine à états. L'idée est de créer un modèle logiciel simple de votre système et une série de commandes qui peuvent être exécutées à la fois sur votre modèle et sur l'implémentation réelle. La propriété est que l'état du modèle et l'état du système réel doivent toujours correspondre.
`fast-check` fournit `fc.commands` à cette fin. Modélisons un simple compteur :
// L'implémentation réelle
class Counter {
constructor() { this.count = 0; }
increment() { this.count++; }
decrement() { this.count--; }
get() { return this.count; }
}
// Les commandes pour fast-check
const incrementCmd = fc.command(
// check: une fonction pour vérifier si la commande peut être exécutée sur le modèle
(model) => true,
// run: une fonction pour exécuter la commande à la fois sur le modèle et le système réel
(model, real) => {
model.count++;
real.increment();
expect(real.get()).toBe(model.count);
}
);
const decrementCmd = fc.command(
(model) => true,
(model, real) => {
model.count--;
real.decrement();
expect(real.get()).toBe(model.count);
}
);
test('Counter devrait se comporter selon le modèle', () => {
fc.assert(
fc.property(fc.commands([incrementCmd, decrementCmd]), (cmds) => {
const model = { count: 0 };
const real = new Counter();
fc.modelRun(() => ({ model, real }), cmds);
})
);
});
Dans ce test, `fast-check` va générer une séquence aléatoire de commandes `increment` et `decrement`, les exécuter à la fois sur notre modèle objet simple et sur la classe `Counter` réelle, et s'assurer qu'ils ne divergent jamais. Cela peut découvrir des bugs subtils dans une logique à état complexe qui seraient presque impossibles à trouver avec des tests basés sur des exemples.
Quand NE PAS utiliser le test basé sur les propriétés
Le PBT est un ajout puissant à votre boîte à outils de test, mais il ne remplace pas toutes les autres formes de test. Ce n'est pas une solution miracle.
Le test basé sur des exemples est souvent meilleur lorsque :
- Vous testez des règles métier spécifiques et connues. Si un calcul de taxe doit produire exactement `10,53 $` pour une entrée spécifique, un simple test basé sur un exemple est plus clair et plus direct. C'est un test de régression pour une exigence connue.
- La "propriété" est simplement "l'entrée X produit la sortie Y". S'il n'y a pas de règle de plus haut niveau, généralisable, sur le comportement de la fonction, forcer un test basé sur les propriétés peut être plus complexe que cela n'en vaut la peine.
- Vous testez la correction visuelle des interfaces utilisateur. Bien que vous puissiez tester la logique d'état d'un composant d'interface utilisateur avec le PBT, la vérification d'une mise en page ou d'un style visuel spécifique est mieux gérée par des tests de snapshot ou des outils de régression visuelle.
La stratégie la plus efficace est une approche hybride. Utilisez les tests basés sur les propriétés pour mettre à l'épreuve vos algorithmes, vos transformations de données et votre logique à état contre un univers de possibilités. Utilisez les tests traditionnels basés sur des exemples pour verrouiller des exigences métier spécifiques et critiques et pour prévenir les régressions sur des bugs connus.
Conclusion : Pensez en propriétés, pas seulement en exemples
Le test basé sur les propriétés encourage un profond changement dans notre façon de penser la correction. Il nous force à prendre du recul par rapport aux exemples individuels et à considérer les principes fondamentaux et les contrats que notre code doit respecter. Ce faisant, nous pouvons :
- Découvrir des cas limites surprenants pour lesquels nous n'aurions jamais pensé à écrire des tests.
- Gagner une confiance beaucoup plus élevée dans la robustesse de notre code.
- Écrire des tests plus expressifs qui documentent le comportement de notre système plutôt que simplement sa sortie sur quelques entrées.
- Réduire considérablement le temps de débogage grâce à la puissance de la réduction.
Adopter le test basé sur les propriétés peut sembler peu familier au début, mais l'investissement en vaut la peine. Commencez petit. Choisissez une fonction pure dans votre base de code — une qui gère la transformation de données ou un calcul complexe — et essayez de définir une propriété pour elle. Ajoutez un test basé sur les propriétés à votre prochain projet. Lorsque vous le verrez trouver son premier bug non trivial, vous serez convaincu de son pouvoir pour construire des logiciels meilleurs et plus fiables pour un public mondial.
Ressources supplémentaires
- Documentation officielle de fast-check
- Comprendre le test basé sur les propriétés par Scott Wlaschin (une introduction classique, indépendante du langage)